#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys
import socket
import threading
import ftplib
import os

def debugmsg(msg):
    """Override as fits."""

CRLF = ftplib.CRLF


def clearwhites(s):
    """This is not a racist function. I takes all 
    multiple whitespaces away."""
    return ' '.join([x for x in s.split(' ') if x])

class Server:
    """A representation of an FTP server."""
    protocols = ['ftp', 'ftp+ssl'] # accepted protocols
    
    def __init__(self, hostname, **kwargs):
        self.hostname = hostname
        self.username = kwargs.get("username", None)
        self.password = kwargs.get("password", None)
        self.port = kwargs.get("port", 21)
        self.protocol = kwargs.get("protocol", "ftp")
        self.directory = kwargs.get("directory", None)
        
        self.passive = kwargs.get("passive", True)
        self.force_ssl = kwargs.get("force_ssl", False)
        
        self.validate()
        
    def __str__(self):
        port = ''
        if self.port != 21:
            port = ":%s" % self.port
        
        return "%s://%s%s" % (self.protocol, self.hostname, port)
            
    def allstr(self):
        return "%s://%s:%s@%s:%s" % (self.protocol, self.username,
            self.password, self.hostname, self.port)
        
    def validate(self):
        if not self.protocol in self.protocols:
            raise ValueError, "%s is unknown protocol" % self.protocol


class RemoteFile:
    """Represents a file on an FTP server."""
        
    def __init__(self, **kwargs):
        self.filename = kwargs.get("filename", None)
        # the containing directory of this file
        self.dir = kwargs.get("dir", None)
        # the type of this file: f, d or l
        self.type = kwargs.get("type", None)
        # size in bytes
        self.size = int(kwargs.get("size", 0))
        # permissions on this file
        self.unix_perm = kwargs.get("unix_perm", None)
        # owner on server
        self.owner = kwargs.get("owner", None)
        # group on server
        self.group = kwargs.get("group", None)
        # changed date
        self.ctime = kwargs.get("ctime", None)
        # if this is a symbolic link, specify target file
        self.linkto = kwargs.get("linkto", None)
        # a Server object
        self.server = kwargs.get("server")
        
    def __repr__(self):
        return "<RemoteFile %s type: %s>" % \
            (self.filename, self.type)
        
    def __str__(self):
        """Return URL for this file.
        Without username and password and stuff."""
        return "%s%s/%s" % (self.server, self.dir, self.filename)
        
    def allstr(self):
        """Return URL with all information."""
        return "%s%s/%s?%s" % (self.server.allstr(), self.dir, self.filename,
            "&".join(["type=%s"%self.type and self.type]))
        
    def __int__(self):
        return self.size
        
    def ishidden(self):
        if self.filename.startswith('.') or self.filename.endswith('~'):
            return True
        else:
            return False
    
    def islink(self):
        return self.type == 'l'
        
    def isfile(self):
        return self.type == 'f'
        
    def isdir(self):
        return self.type == 'd'
    
    def abs_location(self):
        """Absolute location on server."""
        if self.dir == "/":
            return "/%s" % self.filename
        else:
            return "%s/%s" % (self.dir, self.filename)
            
def remotefile_from_str(s):
    import urllib
    protocol, rest = s.split("://")
    host, rest = urllib.splithost("//" + rest)
    host, port = urllib.splitnport(host, defport=21)
    file, t = urllib.splitquery(rest)
    
    s = Server(host, port=port)
    dir, fn = os.path.split(file)
    rf = RemoteFile(filename=fn, dir=dir, type="f", server=s)
    return rf


def parse_LIST(list_output, dir, server):
    """Interpret LIST output and return RemoteFiles.
    list_output should be in a list format (each line from
    server a string in the list).
    dir is the directory for witch the files are in."""
    
    for line in list_output:
        line = clearwhites(line)
        
        if line.startswith('total'):
            continue
            
        tokens = line.split(' ', 8)
        
        # item will be yielded later
        item = RemoteFile(server=server)
        item.type = line[0]
        item.size = int(tokens[4])
        item.filename = tokens[-1]
        item.unix_perm = tokens[0][1:]
        item.owner = tokens[2]
        item.group = tokens[3],
        item.ctime = ''.join(tokens[5:8])
        item.dir = dir
        
        # correct symbolic link issues
        if line[0] == "l":
            item.filename, item.linkto = item.filename.split(" -> ")
        elif line[0] == "-":
            item.type = "f"
        
        yield item
    
    
def parse_MLSD(mlsd_output, dir, server):
    """Interpret MLSD output."""
    for line in mlsd_output:
        attrs = line.split(';')
        kwargs = {}
        
        for attr in attrs[:-1]:
            option, value = attr.split('=')
            if option == 'type':
                kwargs[option] = value[0]
                continue
            if option == 'size':
                kwargs[option] = int(value)
                continue
            kwargs[option] = value
        
        kwargs['filename'] = attrs[-1][1:]
        kwargs['dir'] = dir
        
        item = RemoteFile(server=server, **kwargs)
        yield item


class FTP(ftplib.FTP):
    """Improvements to ftplib.FTP"""
    
    
class FTPThread(FTP, threading.Thread):
    """FTP Thread built on ftplib.FTP, but with significant 
    improvements to the standard function, SSL, MLST and 
    MLSD commands to name a few.
        
    Current job will be in self.current_job
    Waiting jobs in self.jobs, clear with jobs = []

    Some jobs can be aborted:
    * retrbinary can be stopped by switching self.abor to true
        ABOR will be sent and data connection closed.

    Callbacks: (Arguments...)
    * connected: Socket connected to host (welcome_msg)
    * connect_error: Socket could not connect to host (exception)
    * authenticated: Authentication was successful (response)
    * authentication_error: Authentication error (error_str)
    * sending: About to send this (data)
    * received: Received line (data)
    * disconnected: Disconnected from server (reason)
    * pwd: Workin directory (directory)
    * cwd: Current directory changed (msg)
    * cwd_error: Error on changing directories (reason)
    * dir: Output from LIST (list)
    * timeout: General timeout (exception)
    * retr_pos: Bytes read of current receiving file (stream position, cmd)
                cmd = the original line sent to server to initiate the transfer
    * transfer_finished: This transfer has finished (cmd)
    * transfer_aborted: This transfer was aborted (cmd)
    * unhandled_exception: Unhandled exception occured (job, exception)
    * ssl_enabled: Data-connection has engaged encryption (server, issuer)
    * ssl_not_availible: Called when we find out that there is no SSL support (str)
    * mlsd: the output from this command ()
    * deleted: File was deleted (filename)
    * delete_error: File was not deleted (filename, error_string)
    * walk: Items in one directory from walk (item, **kwargs)
    * walk_end: ()
    * walk_error: 
    * list_parents_finished: ()
    * downloaded: File downloaded (remote, to)
    * download_error: Error in download() (e)
    * stor_pos: Sent bytes via STOR. 
    * stor_error: Error in upload (e)
    """
    
    _all_callbacks = ['connected', 'connect_error', 'authenticated',
    'authentication_error', 'sending', 'received', 'disconnected', 'pwd',
    'cwd', 'cwd_error', 'dir', 'timeout', 'retr_pos', 'transfer_finished',
    'transfer_aborted', 'unhandled_exception', 'ssl_enabled', 'mlsd',
    'listing', 'walk', 'deleted', 'delete_error', 'ssl_not_availible', 'walk_end',
    'list_parents_finished', 'stor_pos', 'stor_error']
    
    def __init__(self, socket_timeout=0, data_socket_timeout=0, 
        daemon=False, server=None):
        """
        Arguments:
            socket_timeout => The timeout in the control-connection
            data_socket_timeout => Timeout for data-connection
            daemon => whether to daemonize thread
            server => Server object to use
        """

        threading.Thread.__init__(self)
        
        self.socket_timeout = socket_timeout
        self.data_socket_timeout = data_socket_timeout
        self.feats = []
        
        self.ssl_buffer = ''
        self.ssl_sock = None
        
        self.abor = False
        
        self.callbacks = {}
        self.jobs = []
        self.current_job = None
        
        self.wd = ""
        self.list_cache = {}
        
        # Server object (not needed until connect())
        self.server = server
        
        self.lock = threading.Semaphore()
        self.lock.acquire(True) # a new lock can be released once
        
        self.setDaemon(daemon)
        if not self.isAlive():
            self.start()
        
    def getline(self):
        """ Internal: Reads one line from (ssl)socket """
        if self.ssl_sock:
            while not self.ssl_buffer.count(CRLF):
                self.ssl_buffer += self.ssl_sock.read()
            
            pos = self.ssl_buffer.find(CRLF) + 2
            
            line = self.ssl_buffer[:pos]
            self.ssl_buffer = self.ssl_buffer[pos:]
        else:
            line = self.file.readline()
        
        if line:
            if line[-2:] == CRLF: line = line[:-2]
            elif line[-1:] in CRLF: line = line[:-1]
            self.callback('received', line)
        else:
            self.callback('disconnected', line)
        return line
        
    def putline(self, line):
        """ Internal: Write line to (ssl)socket """
        self.callback('sending', line)
        line = line + CRLF
        
        if self.ssl_sock:
            self.ssl_sock.write(line)
        else: 
            self.sock.sendall(line)
        
    def parse_feat_response(self, r):
        """Internal:
        Parse response from FEAT.
        FEATs will be stored in self.feats."""
        if r.count(CRLF):
            r = r.split(CRLF)
        else:
            r = r.split("\n")
        for l in r:
            if not len(l) > 1:
                continue
            if l[1] == "1":
                continue
            self.feats.append(l[1:])
            
    def connect(self, server=None):
        """Connect to host."""
        if server: 
            self.server = server
        self.add_job("_connect")
        
    def _connect(self):
        """Internal."""
        self.set_pasv(self.server.passive)
        msg = "getaddrinfo returns an empty list"
        
        try:
            foo = socket.getaddrinfo(self.server.hostname, 
                self.server.port, 0, socket.SOCK_STREAM)
        except Exception, e:
            self.callback('connect_error', e)
            return
            
        for res in foo:
            af, socktype, proto, canonname, sa = res
            try:
                self.sock = socket.socket(af, socktype, proto)
                if self.socket_timeout:
                    self.sock.settimeout(self.socket_timeout)
                self.sock.connect(sa)
            except socket.error, msg:
                if self.sock:
                    self.sock.close()
                self.sock = None
                continue
            break
            
        if not self.sock:
            self.callback('connect_error', msg)
            return
            
        self.af = af
        self.file = self.sock.makefile('rb')
        self.welcome = self.getresp()
        
        try:
            self.parse_feat_response(self.voidcmd("FEAT"))
        except ftplib.error_perm:
            pass
        
        if "ssl" in self.server.protocol and "AUTH TLS" in self.feats:
            try:
                self.make_ssl()
            except Exception, e:
                self.callback('ssl_not_availible', str(e))
        elif self.server.force_ssl:
            try:
                self.make_ssl()
            except Exception, e:
                self.callback('ssl_not_availible', str(e))
                # jump ship!
                self._quit()
                return
            
        self.callback("connected", self.welcome)
        return self.welcome
        
    def makeport(self):
        """
        Internal: Create a new socket and send a PORT command for it.
        """
        msg = "getaddrinfo returns an empty list"
        sock = None
        for res in socket.getaddrinfo(None, 0, self.af, socket.SOCK_STREAM, 0, socket.AI_PASSIVE):
            af, socktype, proto, canonname, sa = res
            try:
                sock = socket.socket(af, socktype, proto)
                if self.data_socket_timeout:
                    sock.settimeout(self.data_socket_timeout)
                sock.bind(sa)
            except socket.error, msg:
                if sock:
                    sock.close()
                sock = None
                continue
            break
        if not sock:
            raise socket.error, msg
        sock.listen(1)
        port = sock.getsockname()[1] # Get proper port
        host = self.sock.getsockname()[0] # Get proper host
        if self.af == socket.AF_INET:
            resp = self.sendport(host, port)
        else:
            resp = self.sendeprt(host, port)
        return sock
        
    def ntransfercmd(self, cmd, rest=None):
        """
        Internal:
        
        Initiate a transfer over the data connection.

        If the transfer is active, send a port command and the
        transfer command, and accept the connection.  If the server is
        passive, send a pasv command, connect to it, and start the
        transfer command.  Either way, return the socket for the
        connection and the expected size of the transfer.  The
        expected size may be None if it could not be determined.

        Optional `rest' argument can be a string that is sent as the
        argument to a RESTART command.  This is essentially a server
        marker used to tell the server to skip over any data up to the
        given marker.
        """
        size = None
        if self.passiveserver:
            host, port = self.makepasv()
            af, socktype, proto, canon, sa = socket.getaddrinfo(host, port,
                                                    0, socket.SOCK_STREAM)[0]
            conn = socket.socket(af, socktype, proto)
            if self.data_socket_timeout:
                conn.settimeout(self.data_socket_timeout)
            conn.connect(sa)
            if rest is not None:
                self.sendcmd("REST %s" % rest)
            resp = self.sendcmd(cmd)
            if resp[0] != '1':
                raise error_reply, resp
        else:
            sock = self.makeport()
            if rest is not None:
                self.sendcmd("REST %s" % rest)
            resp = self.sendcmd(cmd)
            if resp[0] != '1':
                raise error_reply, resp
            conn, sockaddr = sock.accept()
        if resp[:3] == '150':
            # this is conditional in case we received a 125
            size = ftplib.parse150(resp)
        return conn, size
    
        
    def login(self):
        """Send login commands."""
        self.add_job("_login")
        
    def _login(self):
        try:
            resp = ftplib.FTP.login(self, 
                user=self.server.username, passwd=self.server.password)
        except ftplib.error_reply, e:
            self.callback("authentication_error", e)
        except Exception, e:
            self.callback("authentication_error", e)
        else:
            if not self.feats:
                self.parse_feat_response(self.voidcmd("FEAT"))
            self.callback("authenticated", resp)
            
    def mlsd(self, argument=''):
        """ Send MLSD command (use self.ls instead!) """
        self.add_job("_mlsd", argument=argument)
        
    def _mlsd(self, argument='', hidden=False):
        output = []
        try:
            resp = self.retrlines(argument and "MLSD %s" % argument \
                                                or "MLSD", output.append)
        except socket.timeout, e:
            self.callback("timeout", e)
            return
        self.callback('mlsd', output)
        return resp, output
            
    def make_ssl(self):
        """ Creates self.ssl_sock socket. Returns AUTH TLS reply. """
        debugmsg("Creating SSL socket")
        
        resp = self.voidcmd("AUTH TLS")
        self.ssl_sock = socket.ssl(self.sock)
        self.ssl_initialized = True
        
        return resp
        
    def callback(self, callback_key, *args, **kwargs):
        """Internal"""
        if self.callbacks.has_key(callback_key):
            for func in self.callbacks[callback_key]:
                try:
                    func(*args, **kwargs)
                except Exception, e:
                    import traceback, sys
                    print "Fatal error in callback, trace:"
                    traceback.print_exc(file=sys.stdout)
            
    def add_job(self, jobname, *args, **kwargs):
        """Internal: Adds a job to the queue"""
        self.jobs.append((jobname, args, kwargs))
        self.lock.release()
            
    def quit(self):
        self.add_job("_quit")
        
    def _quit(self):
        resp = ftplib.FTP.quit(self)
        self.callback("disconnected", str(resp))
        
    def pwd(self):
        """ Print working directory """
        self.add_job("_pwd")
        
    def _pwd(self):
        resp = ftplib.FTP.pwd(self)
        self.wd = resp
        self.callback("pwd", resp)
        return resp
        
    def cwd(self, d):
        """ Change working directory """
        self.add_job("_cwd", d)
        
    def _cwd(self, d):
        try:
            resp = ftplib.FTP.cwd(self, d)
        except (ftplib.error_reply, ftplib.error_perm), e:
            self.callback("cwd_error", str(e))
            return False
        else:
            self.callback("cwd", resp)
            return True
    
    def dir(self):
        """ Send LIST command (use self.ls instead!) """
        self.add_job("_dir")
        
    def _dir(self, hidden=False, *args):
        output = []
        try:
            if hidden:
                resp = ftplib.FTP.dir(self, '-l', output.append)
            else:
                resp = ftplib.FTP.dir(self, output.append)
        except socket.timeout, e:
            self.callback("timeout", e)
            return
        
        return resp, output

    def retrbinary(self, *args, **kwargs):
        self.add_job("_retrbinary", *args, **kwargs)
        
    def _retrbinary(self, cmd, callback, blocksize=8192, rest=None):
        """Internal: Retrieve data in binary mode.

        `cmd' is a RETR command.  `callback' is a callback function is
        called for each block.  No more than `blocksize' number of
        bytes will be read from the socket.  Optional `rest' is passed
        to transfercmd().

        A new port is created for you.  Return the response code.
        
        ThreadFTP: Add a position callback, returns -1 once complete.
                   Checks for self.abor
                   Calls callback with finish=True once no more callbacks
                    to this method will be done.
        """
        self.voidcmd('TYPE I')
        conn = self.transfercmd(cmd, rest)
        pos = 0
        if rest:
            pos = rest
        while not self.abor:
            data = conn.recv(blocksize)
            if not data:
                self.callback('retr_pos', -1, cmd)
                self.callback('transfer_finished', cmd)
                callback('', finish=True)
                break
            pos += len(data)
            self.callback('retr_pos', pos, cmd)
            callback(data)
        conn.close()
        if self.abor:
            try:
                self.getmultiline()
            except EOFError: pass
            self.callback('transfer_aborted', cmd)
            self.abor = False
            return
        return self.voidresp()
        
    def storbinary(self, *args, **kwargs):
        self.add_job("_storbinary", *args, **kwargs)
        
    def _storbinary(self, cmd, fp, blocksize=8192):
        '''Store a file in binary mode.'''
        self.voidcmd('TYPE I')
        conn = self.transfercmd(cmd)
        while not self.abor:
            buf = fp.read(blocksize)
            if not buf: break
            conn.sendall(buf)
        if self.abor:
            self.abor = False
        conn.close()
        return self.voidresp()
        
    def quote(self, line):
        """ Send a line """
        self.add_job("sendcmd", line)
        
    def sendcmd(self, *args, **kwargs):
        """ Internal: Added timeout handling. """
        try:
            return ftplib.FTP.sendcmd(self, *args, **kwargs)
        except socket.timeout, e:
            self.callback("timeout", e)
        
    def run(self):
        """ Internal: Thread loop """
        while 1:
            # Wait for a job to enter
            self.lock.acquire(True)
            try:
                job, args, kwargs = self.jobs.pop(0)
                self.current_job = (job, args, kwargs)
            except IndexError:
                debugmsg("Lock released, no job.")
                continue
            else:
                debugmsg("Lock released, job: %s %s %s" %(job, args, kwargs))
            if job == "join":
                break # Break out of loop, join thread
            try:
                getattr(self, job)(*args, **kwargs)
            except Exception, e:
                self.callback('unhandled_exception', (job, args, kwargs), e)
                print "Non-fatal exception in job, traceback:"
                import traceback, sys
                traceback.print_exc(file=sys.stdout)
            self.current_job = None
        
    def add_callbacks(self, c_map):
        """
        Use map to add callbacks.
        """
        for key in c_map.keys():
            if self.callbacks.has_key(key):
                self.callbacks[key].append(c_map[key])
            else:
                self.callbacks[key] = [c_map[key]]
                
    def remove_callbacks(self, c_map):
        """
        Remove functions from callbacks
        """
        for key in c_map:
            self.callbacks[key].remove(c_map[key])
            
    def flush_cache(self):
        """ Flushes the directory listing cache """
        self.list_cache = {}
        
    def ls(self, **kw):
        """ List files in current directory. """
        self.add_job("_ls")
        
    def _ls(self, **kw):
        if self.wd in self.list_cache.keys():
            # Use cache
            listing = self.list_cache[self.wd]
            resp = None
        elif "MLSD" in self.feats:
            resp, output = self._mlsd(**kw)
            listing = list(parse_MLSD(output, self.wd, self.server))
            self.list_cache[self.wd] = listing
        else:
            resp, output = self._dir(**kw)
            listing = list(parse_LIST(output, self.wd, self.server))
            self.list_cache[self.wd] = listing
        
        self.callback("listing", listing)
        return resp, listing
        
    def delete(self, item):
        self.add_job("_delete", item)
        
    def _delete(self, item):
        if self.wd == item.dir:
            fs = item.filename
        else:
            fs = item.abs_location()
            
        try:
            if item.type == 'f':
                ftplib.FTP.delete(self, fs)
            elif item.type == 'd':
                ftplib.FTP.rmd(self, fs)
        except ftplib.error_perm, e:
            self.callback('delete_error', fs, str(e))
        else:
            self.callback('deleted', fs)
        
    def join(self):
        self.add_job("join")
               
    def busy(self):
        """Return True if this thread is doing a job or has waiting jobs."""
        return bool(self.current_job or self.jobs)
        
    def walk(self, d, **kwargs):
        self.add_job("_walk", d, **kwargs)
        
    def _walk(self, d, **kwargs):
        def cwd(h):
            r = self._cwd(h)
            self._pwd()
            return r
            
        def dodir():
            resp, listing = self._ls()
            for item in listing:
                self.callback('walk', item, **kwargs)
                if item.isdir():
                    if cwd(item.filename):
                        dodir()
                        cwd('..')
        
        if cwd(d):
            dodir()
            cwd('..')
        self.callback('walk_end')
        self._ls()
    
    def list_parents(self):
        self.add_job("_list_parents")
        
    def _list_parents(self):
        """Go to root, list, next, list, repeat."""
        wdsplit = [x for x in self.wd.split("/") if x]
        for i in xrange(len(wdsplit)+1):
            path = "/" + "/".join(wdsplit[:i])
            self._cwd(path)
            self._pwd()
            self._ls()
        self.callback('list_parents_finished')
        
    def download(self, remote, to, rest=False):
        self.add_job("_download", remote, to, rest)
        
    def _download(self, remote, to, rest):
        """Download a RemoteFile."""
        # check if we're already on the correct server
        if not remote.server is self.server:
            self.server = remote.server
            if self.sock: self._quit()
            
            # TODO: this does not handle errors.
            self._connect()
            self._login()
            
        if not self.wd == remote.dir:
            self._cwd(remote.dir)
        
        seek = 0
        if rest and os.path.isfile(to):
            seek = os.path.getsize(to)
        try:
            fd = open(to, seek and "a" or "w")
        except:
            self.callback('download_error', 
                "Could not open file for writing: %s" % to)
            return
        
        def cb(d, finish=False):
            fd.write(d)
            if finish:
                fd.close()
        
        try:
            self._retrbinary("RETR %s" % remote.filename, cb, 
                rest=(seek and str(seek) or None))
        except ftplib.error_perm:
            self.callback('download_error', "Permission denied")
        except Exception, e:
            self.callback('download_error', str(e))
        
    def upload(self, local, remote):
        self.add_job("_upload", local, remote)
        
    def _upload(self, local, remote):
        if not remote.server is self.server:
            self.server = remote.server
            if self.sock: self._quit()
            
            self._connect()
            self._login()
        
        if not self.wd == remote.dir:
            self._cwd(remote.dir)
        
        try:
            fd = open(local, "r")
            self._storbinary("STOR %s" % remote.filename, fd)
        except ftplib.error_perm:
            self.callback('stor_error', "Permission denied")
        except Exception, e:
            self.callback('stor_error', str(e))
            

if __name__ == "__main__":
    print "This is a library."

